iT邦幫忙

2026 iThome 鐵人賽

DAY 14
1
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 14 篇

Day 14:簡化的排程邏輯 - 讓計畫真的排得進使用者的時間

  • 分享至 

  • xImage
  •  

Day 13 的 Planner 已經能生成包含週數、主題與任務的學習計畫。不過任務時數是否超過使用者每週可用時間,目前仍只靠 Prompt 要求模型留意,沒有程式規則把關。開源模型可能排出每週 15 小時的計畫,但使用者只有 10 小時可用。

今天改用純程式邏輯實作排程演算法。它會接收 Planner 生成的原始任務清單,重新安排日期,確保不超過每週可用時間,並在每週固定加入複習。排程規則明確,用 if/else 與排序處理即可,不需要 LLM 介入。

今天完成後,你會有:

  • 一支 scheduler.py,輸入原始任務清單和每週可用時間,輸出重新排程過的日曆
  • POST /plans/generate 串接排程演算法,存進資料庫的任務時間都經過驗證
  • 驗證:排程後每一週的時數加總,不會超過使用者每週可用時間

排程要解決的問題

Planner 生成的任務清單包含 day(第幾天)和 estimated_hours(預估時數),但尚未驗證安排是否可行。今天的排程會做五件事:

  1. 按 deadline 排序:這裡的 deadline 就是任務原本的 day,越早要做的先排
  2. 按難度分組:用時數推算任務難度(簡單/中等/困難),困難任務不特別拆解,但分類後面用得到
  3. 依可用時間分配:把任務塞進「一週」的時數預算裡,塞滿了才換下一週
  4. 超時就向後延期:本週排不下的任務,自動延到下一週開頭,不會硬塞爆
  5. 固定位置插入複習:每一週的最後一天,固定安排一個複習任務

這五步都有明確規則,適合寫成函式處理。

為什麼用時數推算難度,而不是加一個難度欄位

Day 4 設計 tasks 表時沒有 difficulty 欄位,Day 13 的 PlanTask Schema 也不要求模型標示難度。因此今天直接以時數作為難度訊號:預估時數短的任務通常較簡單,長的通常較難。用門檻值換算即可,無須調整資料庫或增加模型輸出欄位。

排程演算法的核心邏輯

演算法以「本週時數預算」安排任務。每週預算等於使用者的 available_hours 扣除複習固定占用的 1 小時。任務依排序逐一加入;若加入後超過預算,就先插入複習任務,再開啟下一週。所有任務排完後,最後一週也會補上複習。

任務清單(按day排序)
  ↓
本週還裝得下嗎?
  ├─ 裝得下 → 加進本週,累計時數
  └─ 裝不下 → 插入本週複習 → 開新的一週 → 這個任務排進新一週
  ↓
(重複直到任務排完)
  ↓
補上最後一週的複習

實作步驟

步驟1:寫 scheduler.py

檔案位置: backend/scheduler.py
狀態: 新增檔案
用途: 純程式邏輯的排程演算法,不呼叫任何LLM
依賴: dataclasses(Python標準庫)

from dataclasses import dataclass

REVIEW_HOURS = 1.0  # 複習固定佔用的時數,直接從每週可用時間裡扣除


@dataclass
class ScheduledTask:
    """排程後的一項任務"""
    day: int
    title: str
    estimated_hours: float
    difficulty: str
    is_review: bool = False


def _difficulty(estimated_hours: float) -> str:
    """用預估時數推算任務難度,作為分組依據"""
    if estimated_hours <= 1.0:
        return "簡單"
    if estimated_hours <= 3.0:
        return "中等"
    return "困難"


def _review_task(week_num: int) -> ScheduledTask:
    """產生固定放在每週最後一天(第7天)的複習任務"""
    return ScheduledTask(
        day=week_num * 7,
        title=f"複習第{week_num}週內容",
        estimated_hours=REVIEW_HOURS,
        difficulty="簡單",
        is_review=True,
    )


def reschedule_tasks(
    tasks: list[dict], available_hours_per_week: float
) -> list[ScheduledTask]:
    """
    重新排程Planner生成的原始任務清單。

    tasks: 每項至少要有day、title、estimated_hours三個key(Day13 PlanTask的格式)
    available_hours_per_week: 使用者每週可用時間,來自Day7的Profile

    回傳:排序後的ScheduledTask清單,每週最後一天固定是複習任務,
    且每一週的時數加總都不會超過available_hours_per_week
    """
    if available_hours_per_week <= REVIEW_HOURS:
        raise ValueError("每週可用時間必須大於複習所需的時數")

    budget = available_hours_per_week - REVIEW_HOURS
    sorted_tasks = sorted(tasks, key=lambda t: t["day"])

    schedule: list[ScheduledTask] = []
    week_num = 1
    week_hours = 0.0
    day_in_week = 1

    for task in sorted_tasks:
        hours = task["estimated_hours"]

        # 這個任務會讓本週超時:先收尾插入複習,再開新的一週
        if week_hours + hours > budget:
            schedule.append(_review_task(week_num))
            week_num += 1
            week_hours = 0.0
            day_in_week = 1

        schedule.append(
            ScheduledTask(
                day=(week_num - 1) * 7 + day_in_week,
                title=task["title"],
                estimated_hours=hours,
                difficulty=_difficulty(hours),
            )
        )
        week_hours += hours
        day_in_week = min(day_in_week + 1, 6)  # 第7天固定留給複習

    schedule.append(_review_task(week_num))  # 補上最後一週的複習

    return schedule


def validate_weekly_hours(
    schedule: list[ScheduledTask], available_hours_per_week: float
) -> bool:
    """驗收用:檢查排程後每一週的總時數是否都沒有超過使用者可用時間"""
    weekly_totals: dict[int, float] = {}
    for task in schedule:
        week = (task.day - 1) // 7 + 1
        weekly_totals[week] = weekly_totals.get(week, 0.0) + task.estimated_hours

    return all(total <= available_hours_per_week for total in weekly_totals.values())

步驟2:先獨立測試排程邏輯,不透過 API

檔案位置: backend/test_scheduler.py
狀態: 新增檔案
用途: 驗證排程演算法確實不會讓任何一週超過可用時間
依賴: scheduler

from scheduler import reschedule_tasks, validate_weekly_hours

# 故意設計成會超時的原始任務清單:11個任務、每個2小時,
# 但使用者每週只有10小時可用,模擬Planner排太滿的情況
raw_tasks = [
    {"day": i, "title": f"任務{i}", "estimated_hours": 2.0} for i in range(1, 12)
]


def main() -> None:
    available_hours = 10.0
    schedule = reschedule_tasks(raw_tasks, available_hours)

    print(f"原始任務數:{len(raw_tasks)},排程後任務數(含複習):{len(schedule)}\n")

    for task in schedule:
        tag = "【複習】" if task.is_review else f"【{task.difficulty}】"
        print(f"第{task.day}天 {tag} {task.title}({task.estimated_hours}小時)")

    ok = validate_weekly_hours(schedule, available_hours)
    print(f"\n每週時數都沒有超過{available_hours}小時:{ok}")
    assert ok, "排程驗證失敗,某一週超時了"


if __name__ == "__main__":
    main()

執行:

python test_scheduler.py

應該會看到 11 個任務被分成多週,每週滿了就插入複習再換下一週,最後顯示 每週時數都沒有超過10.0小時:True。這支測試不需要 Ollama;相同輸入會得到相同的輸出。

步驟3:串接進 /plans/generate

Day 13 的 create_plan 端點原本是「呼叫 Planner → 直接把 learning_plan.tasks 存進資料庫」。今天在存資料庫之前,多插入一步排程。

檔案位置: backend/main.py
狀態: 修改檔案(取代 Day13 create_plan 裡「把每個任務存進tasks表」那段迴圈)
用途: 生成計畫後先排程,再把排程後的任務存進資料庫
依賴: scheduler

from scheduler import reschedule_tasks

# ...create_plan 函式裡,接續Day13呼叫generate_plan之後:

    # 呼叫排程演算法,依使用者每週可用時間重新分配任務日期,並插入複習
    raw_tasks = [task.model_dump() for task in learning_plan.tasks]
    scheduled_tasks = reschedule_tasks(raw_tasks, profile.available_hours)

    # 把排程後的任務存進tasks表,複習任務用標題前綴標記,不需要改資料庫欄位
    for task in scheduled_tasks:
        title = f"【複習】{task.title}" if task.is_review else task.title
        db.add(
            Task(
                plan_id=plan.id,
                day=task.day,
                title=title,
                estimated_hours=task.estimated_hours,
                status="待做",
                deadline=datetime.now() + timedelta(days=task.day),
            )
        )

    db.commit()

    return PlanGenerateResponse(
        plan_id=plan.id,
        title=plan.title,
        duration_weeks=plan.duration_weeks,
        weekly_topics=learning_plan.weekly_topics,
        task_count=len(scheduled_tasks),  # 含複習任務的實際數量
    )

task_count 改為計算 scheduled_tasks 的長度。排程後每週多一個複習任務,因此它代表實際存進資料庫的任務數。

步驟4:測試

先確認 Ollama 本機模型服務已啟動,在 backend 目錄下啟動後端伺服器:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,呼叫 POST /plans/generate,用跟 Day 13 一樣的請求(假設 user_id 是 1,已經完成 Day 7 的學習檔案):

{
  "user_id": 1,
  "goal_title": "3個月內通過AWS Solutions Architect Associate認證",
  "goal_deadline_weeks": 12
}

回應的 task_count 應該比 Day 13 測試時多(多了複習任務)。接著用資料庫工具(Day 5 提過的 SQLite Browser)打開 tasks 表,用 plan_id 篩選這次生成的任務,確認:

  • 每一筆任務的 day 都落在合理範圍內,沒有一堆任務擠在同一天
  • 標題有「【複習】」前綴的任務,day 都是 7 的倍數
  • 手動把同一週(day 落在同一個 7 天區間)裡的 estimated_hours 加起來,不會超過這個使用者的 available_hours

常見問題

排程後的週數比 Planner 原本說的 duration_weeks 多

這是預期行為。Planner 產生的 duration_weeks 是模型的估計,Prompt 無法強制任務時數塞進指定週數。排程演算法會重新檢查時數;若任務量超出使用者實際可用時間,就把多出的任務延到後續週次,因此總週數可能增加。

為什麼複習任務都排在第 7、14、21 天,不是平均分散?

今天的複習規則採固定位置,不會依使用者答錯狀況動態調整。Day 22 才會根據遺忘曲線與答錯記錄調整複習間隔。現階段只確保每週結束都有一個複習任務。

ValueError: 每週可用時間必須大於複習所需的時數

代表這個使用者的 available_hours 小於等於 1 小時(REVIEW_HOURS 的值),排不進任何複習時段。檢查 Day 7 建立的學習檔案,available_hours 是不是誤填成太小的數字。

如果任務本身的預估時數就超過使用者一整週的可用時間怎麼辦?

例如某個任務 estimated_hours 是 12 小時,但使用者每週只有 10 小時可用,這種任務無論排到哪一週都會讓那一週超時。目前的演算法沒有處理「拆解單一任務」這件事,這種任務會被排入新的一週,但那一週仍然會超時,validate_weekly_hours 會抓到。實務上這種情況通常代表 Planner 生成的單一任務範圍設計得太大,比較合理的處理方式是回頭調整 Day 13 的 Prompt,要求模型把任務拆得更細(例如限制單一任務不超過某個時數),而不是在排程階段硬拆。

為什麼不直接在資料庫加 difficulty 和 is_review 欄位?

今天以時數推算難度,並用標題前綴標示複習,避免調整 Day 4 已定案的資料庫結構。這些資訊只在排程階段使用;存入資料庫後,前端目前只需讀取標題與時間。未來若要以不同樣式呈現複習任務,再為 tasks 表新增欄位即可。


進度回顧

今天完成不呼叫 LLM 的 scheduler.py。它透過排序、分組、時數預算與固定插入複習,驗證 Planner 生成的任務是否能排進使用者的時間。POST /plans/generate 現在會先生成計畫、再排程,最後才存入資料庫。

系統現在是這樣的:

Day 1  ✓ 產品定義完成
Day 2  ✓ 開發環境準備
Day 3  ✓ 專案架構設計
Day 4  ✓ 資料庫設計
Day 5  ✓ SQLite 資料庫建置
Day 6  ✓ FastAPI 基礎
Day 7  ✓ 使用者檔案 API
Day 8  ✓ 理解 LLM Agent 的本質
Day 9  ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent
Day 14 ✓ 簡化的排程邏輯(今天)
Day 15 ⬜ 計畫 CRUD API

計畫現在能生成與排程,但使用者仍無法單獨查詢、修改或刪除計畫,也不能將任務標記為完成、跳過或延期。明天會補齊這些操作,完成 CRUD API。


上一篇
Day 13:計畫生成 Agent(Planner)
下一篇
Day 15:計畫 CRUD API - 讓使用者能真正控制 AI 的計畫
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-09-28 21:06:14

今天沒有通知我

我要留言

立即登入留言